<?php
/**
 * Moodle formulas question type class.
 *
 * @copyright &copy; 2010 Hon Wai, Lau
 * @author Hon Wai, Lau <lau65536@gmail.com>
 * @license http://www.gnu.org/copyleft/gpl.html GNU Public License version 3
 * @package questionbank
 * @subpackage questiontypes
 */

require_once("$CFG->dirroot/question/type/questiontype.php");
require_once("$CFG->dirroot/question/type/formulas/variables.php");
require_once("$CFG->dirroot/question/type/formulas/answer_unit.php");
require_once("$CFG->dirroot/question/type/formulas/conversion_rules.php");

/**
 * The formulas question class
 *
 * TODO give an overview of how the class works here.
 */
class question_formulas_qtype extends default_questiontype {

    function name() {
        return 'formulas';
    }
    
    
    /// return the tags of subquestion answer field of the database/variable
    function subquestion_answer_tags() {
        return array('placeholder','answermark','vars1','answer','vars2','correctness'
            ,'unitpenalty','postunit','ruleid','otherrule','subqtext','feedback');
    }
    
    
    /// return the extra options field of the formulas question type
    function subquestion_option_extras() {
        return array('varsrandom', 'varsglobal', 'retrymarkseq', 'peranswersubmit', 'showperanswermark');
    }
    
    
    /// Attempt to get the options in the database, return non-zero value if fail
    function get_question_options_part(&$question) {
        if (!$question->options->extra = get_record('question_formulas', 'questionid', $question->id))  return 1;
        if (!$question->options->answers = get_records('question_formulas_answers', 'questionid', $question->id, 'id ASC'))  return 2;
        if (count($question->options->answers) == 0)  return 3; // It must have at least one answer
        $question->options->answers = array_values($question->options->answers);
        return 0;
    }
    
    
    /// All the additional datum generated for this question type are stored in $question->options
    function get_question_options(&$question) {
        try {
            if ( ($errcode = $this->get_question_options_part($question)) != 0)
                throw new Exception('Error: Missing formulas question options for questionid ' . $question->id . '. Error ' . $errcode);
            $question->options->numpart = count($question->options->answers);
            $question->options->pretexts = explode('{#}',$question->questiontext);
            $question->options->posttext = array_pop($question->options->pretexts);
            if (count($question->options->pretexts) != $question->options->numpart)
                throw new Exception('Error: Inconsistency between number of placeholder and number of answer');
            $errors = $this->append_subanswer_format($question->options->answers);
            if (count($errors) > 0)
                throw new Exception('Error: Subanswer format incorrect.');
            foreach ($question->options->answers as $answer)  $question->options->numresponse += count($answer->names);
        } catch (Exception $e) {
            notify($e->getMessage());
            return false;
        }
        return true;
    }
    
    
    /// Attempt to insert or update a record in the database. May throw error
    function question_options_insertdb($dbname, &$record, $oldid) {
        if (isset($oldid)) {
            $record->id = $oldid;    // if there is old id, reuse it.
            if (!update_record($dbname, $record))
                throw new Exception("Could not update quiz record in database $dbname! (id=$oldid)");
        }
        else {
            if (!$record->id = insert_record($dbname, $record))
                throw new Exception("Could not insert quiz record in database $dbname! (id=$oldid)");
        }
    }


    /// Save the varsdef, answers and units to the database tables from the editing form
    function save_question_options($question) {
        $errcode = $this->get_question_options_part($question); // get old options from the database, id will be reused if it exist
        $oldextra = ($errcode == 0 || $errcode >= 2) ? $question->options->extra : null;
        $oldanswers = ($errcode == 0) ? $question->options->answers : null;
        
        try {
            $filtered = $this->check_and_filter_answers($question); // data from the web input interface should be validated
            if (isset($filtered->errors))   // there may be error from import or restore
                throw new Exception('Format error! Probably import/restore files have been damaged.');
            if (count(explode('{#}',$question->questiontext))-1 != count($filtered->answers))
                throw new Exception('Error! Number of subquestion not equal to placeholder. Probably import/restore files have been damaged.');
            if (isset($question->answerorders)) // reorder the answer, so that the ordering is the same as the placeholder appearing in the main text
                foreach ($question->answerorders as $newloc)  $newanswers[] = $filtered->answers[$newloc];
            else
                $newanswers = $filtered->answers;
            
            $idcount = 0;
            foreach ($newanswers as $i=>$ans) {
                $this->question_options_insertdb('question_formulas_answers', $ans, isset($oldanswers[$idcount]) ? $oldanswers[$idcount++]->id : null);
                $newanswerids[$i] = $ans->id;
            }
            
            // delete remaining used records
            for ($i=count($newanswers); $i<count($oldanswers); ++$i)
                delete_records('question_formulas_answers', 'id', $oldanswers[$i]->id);
            
            $newextra = new stdClass;
            $newextra->questionid  = $question->id;
            $extras = $this->subquestion_option_extras();
            foreach ($extras as $extra)  $newextra->$extra = trim($question->$extra);
            $newextra->answerids   = implode(',',$newanswerids);
            $this->question_options_insertdb('question_formulas', $newextra, isset($oldextra) ? $oldextra->id : null);
        } catch (Exception $e) {
            return (object)array('error' => $e->getMessage());
        }
        
        return true;
    }


    /// Override the parent save_question in order to change the defaultgrade.
    function save_question($question, $form, $course) {
        $form->defaultgrade = array_sum($form->answermark); // the default grade is the total grade of its subquestion
        $filtered = $this->check_and_filter_answers($form); // data from the web input interface should be validated
        if (isset($filtered->errors))   // there may be error from import or restore
            error('Error! Probably import/restore files have been damaged.');
        $ss = $this->create_subquestion_structure($form->questiontext, $filtered->answers);
        $form->questiontext = implode('{#}',$ss->pretexts).'{#}'.$ss->posttext; // replace the questiontext before stored in the database
        $form->answerorders = $ss->answerorders;    // add the answerorders for later reordering
        return parent::save_question($question, $form, $course);
    }


    /// Deletes question from the question-type specific tables with $questionid
    function delete_question($questionid) {
        delete_records('question_formulas', 'questionid', $questionid);
        delete_records('question_formulas_answers', 'questionid', $questionid);
        return true;
    }
    
    
    
    
    
    /// Parses the variable texts and then generates a random set of variables for this session
    function create_session_and_responses(&$question, &$state, $cmoptions, $attempt) {
        try {
            $vars = new question_formulas_variables(); // store the random variable
            $vars->parse_random_variables($question->options->extra->varsrandom);
            $vars->instantiate_random_variables(); // instantiate a set of random variable
            $state->randomvars = $vars->get_variables();
            $state->globalvars_text = $question->options->extra->varsglobal;
            $state->subanum = '';   // It has no meaning before the students submit the first answer.
            $state->trials = array_fill(0, $question->options->numpart, 0);
            $state->raw_grades = array_fill(0, $question->options->numpart, 0);
            $state->fractions = array_fill(0, $question->options->numpart, 0);
            $state->corrunits = array_fill(0, $question->options->numpart, 0);
            foreach ($question->options->answers as $answer)  foreach ($answer->names as $name)
                $state->responses[$name] = '';  // fill all response by empty string
            return true;    // success
        } catch (Exception $e) {
            return false;   // fail
        }
    }


    /// Restore the variables and answers from the last session
    function restore_session_and_responses(&$question, &$state) {
        try {
            $lines = explode("\n", $state->responses['']);
            $vars = new question_formulas_variables(); // the first line is the random variables of the session
            $vars->evaluate_additional_variables($lines[0]);   // the $lines[0] is a simple (variable => value) pairs
            $state->randomvars = $vars->get_variables();
            $state->globalvars_text = $question->options->extra->varsglobal;
            $state->subanum = intval($lines[1]);
            $details = array();
            foreach (array_slice($lines,2) as $line) {
                $pair = explode('=', $line, 2);
                if (count($pair) == 2)  $details[$pair[0]] = $pair[1];
            }
            foreach ($question->options->answers as $i => $answer) {
                $grading = array_key_exists($i, $details) ? explode(',', $details[$i]) : array(0,0,0);
                list($state->trials[$i],$state->raw_grades[$i],$state->fractions[$i]) = $grading;
                foreach ($answer->names as $name)
                    $state->responses[$name] = array_key_exists($name, $details) ? $details[$name] : '';
            }
            $state->responses[''] = '';
            return true;    // success
        } catch (Exception $e) {
            notify("Error: Session record is either damaged or inconsistent with the question (#{$question->id}).");
            return false;   // fail
        }
    }
    
    
    /// The first line stores the variables and the following lines store the responses for each subquestions
    function save_session_and_responses(&$question, &$state) {
        $vars = new question_formulas_variables($state->randomvars);
        $responses_str = $vars->get_serialization() . "\n"; // the first line is the variables of the session
        $responses_str .= $state->subanum . "\n"; // this line is submission info, started by submitted answer number
        foreach ($question->options->answers as $i => $answer) {
            if ($state->trials[$i] > 0) // if the subquestion has been tried, store the number of trial and grading result
                $responses_str .= ($i . '=' . $state->trials[$i] . ',' . $state->raw_grades[$i] . ',' . $state->fractions[$i] . "\n");
            foreach ($answer->names as $name) { // store all none-empty responses for each input location
                $ex = explode("\n", isset($state->responses[$name]) ? $state->responses[$name] : '');
                if (strlen($ex[0]) > 0 )  $responses_str .= $name . '=' . $ex[0] . "\n";
            }
        }
        
        // Set the legacy answer field
        if (!set_field('question_states', 'answer', $responses_str, 'id', $state->id)) {
            return false;
        }
        return true;
    }
    
    
    
    
    
    /// Replace variables and format the text before print it out
    function get_substituted_question_texts($question, $cmoptions, $vars, $text, $class_names) {
        if (strlen(trim($text)) == 0)  return '';
        $subtext = $vars->substitute_variables_in_text($text);
        $restext = $this->format_text($subtext, $question->questiontextformat, $cmoptions);
        return ($class_names === '') ? $restext : '<div class="'.$class_names.'">'.$restext.'</div>'."\n";
    }
    
    
    /// Print the marks for each answer, if the showperanswermark is checked
    function get_per_answer_mark(&$question, $state, $cmoptions, $options, $i) {
        if ($question->options->extra->showperanswermark) {
            $maxmark = $question->options->answers[$i]->answermark; // there is no scaling of grade...
            if ($state->last_graded->trials[$i] < 1)    // if not graded, display -/mark
                return '<span class="grade formulas_mark"> Mark: -/'.$maxmark.'</span>';
            else {
                $resstr = '<span class="grade formulas_mark"';
                if ($cmoptions->penaltyscheme) {
                    $tmf = $this->get_trial_mark_fraction($question, $state->last_graded->trials[$i]);
                    $resstr .= ' title="Grading result for the previous submission. Trial #'.$state->last_graded->trials[$i]
                        .', Trial maximum mark: '.($tmf[0]*100).'%'
                        .', Raw mark: '.($maxmark*$state->last_graded->fractions[$i])
                        .', Result: '.($maxmark*$state->last_graded->fractions[$i]*$tmf[0]).'/'.$maxmark.'"';
                }
                return $resstr . '> Mark: '.$state->last_graded->raw_grades[$i].'/'.$maxmark.'</span>';
            }
        }
    }
    
    
    /// Get the submission information that will be shown when the mouse hovering over the submit button
    function get_submission_info($question, $state, $cmoptions, $options, $i) {
        $tmf = $this->get_trial_mark_fraction($question, $state->last_graded->trials[$i]+1);
        return ($tmf[1]>0 ? ($tmf[1]-$state->last_graded->trials[$i]).' trial remains. ' : 'Trial unlimited. ')
            .'Maximum mark for this submission: '.($tmf[0]*100).'%"';
    }
    
    
    /// return the html for the submit button
    function get_per_answer_submit_button($question, $state, $cmoptions, $options, $i, $submit_location_id, $subans_track_id) {
        if ($question->options->extra->peranswersubmit && ($cmoptions->optionflags & QUESTION_ADAPTIVE) && !$options->readonly) {
            if ($cmoptions->penaltyscheme) {
                $tmf = $this->get_trial_mark_fraction($question, $state->last_graded->trials[$i]+1);
                $info = $this->get_submission_info($question, $state, $cmoptions, $options, $i);
                if ($tmf[0] < 0)  return '';
                return '<span title="'.$info.'"><input type="button" class="btn formulas_submit" value="Submit" onclick="'
                    ."formulas_submit('$submit_location_id','$submit_location_id','$subans_track_id','$i','{$question->id}')".'"></span>';
            }
        }
        return '';
    }
    
    
    /// Print the question text and its subquestions answer box, give feedback if submitted.
    function print_question_formulation_and_controls(&$question, &$state, $cmoptions, $options) {
        global $CFG;
        $submit_location_id = $question->name_prefix.'submit';    // it should have no same question on the same page (?)
        $subans_track_id = $question->name_prefix.'subanum';
        
        // -------------- print the image if any --------------
        if ($image = get_question_image($question, $cmoptions->course)) {
            echo '<img class="qimage" src="'.$image.'" alt="" /><br />'."\n";
        }
        
        // -------------- display question body --------------
        try {
            $globalvars = $this->get_global_variables(&$state);
            foreach ($question->options->answers as $i => $part)
                $this->get_local_variables($part, &$state);
        } catch (Exception $e) {
            notify("Error: Question evaluation failure, probably the question is changed and is not checked.");
            return;
        }
        foreach ($question->options->answers as $i => $part) {
            $vars = $this->get_local_variables($part, &$state);
            $subqreplaced = $this->get_substituted_question_texts($question, $cmoptions, $vars, $part->subqtext, '');
            $showfeedback = $options->feedback && ($state->subanum == -1 || $state->subanum == $i);
            $feedbackclass = $showfeedback ? $divclass = question_get_feedback_class($state->fractions[$i]) : '';
            $submit_button = $this->get_per_answer_submit_button($question, $state, $cmoptions, $options, $i, $submit_location_id, $subans_track_id);
            $tmf = $this->get_trial_mark_fraction($question, $state->last_graded->trials[$i]+1);
            $readonly = (!empty($options->readonly) || $tmf[0] < 0) ? 'disabled="disabled"' : ''; // disable if readonly or no more trial
            $submit_mark = '<span class="formulas_submit_mark">';
            $submit_mark .= trim($submit_button);
            $submit_mark .= $this->get_per_answer_mark($question, $state, $cmoptions, $options, $i);
            $submit_mark .= $showfeedback ? question_get_feedback_image($state->fractions[$i]) : '';
            $submit_mark .= '</span>';
            $feedback = $showfeedback ? $this->get_substituted_question_texts($question, $cmoptions, $vars, $part->feedback, 'feedback') : '';
            
            echo $this->get_substituted_question_texts($question, $cmoptions, $globalvars, $question->options->pretexts[$i], '');
            if (count($part->names) == 1) {     // for one long input box
                $inputbox = '<input type="text" class="formulas_answer '.$feedbackclass.'" '.$readonly.' title="'
                    .'Expect '.count($part->coordinates).' number'.($part->postunit=='' ? '' : ' and a unit').'"'
                    .' name="'.$question->name_prefix.$part->names[0].'" value="'.$state->responses[$part->names[0]].'"/>';
                echo $subqreplaced;
                echo '<div class="ablock clearfix">';
                echo '<div class="formulas_answer_submit_mark">' . $inputbox . $submit_mark . '</div>';
                echo $feedback;
                echo '</div>';
            }
            else {
                foreach ($part->names as $placeholder => $var_name) {  // replace the input box for each placholder {_0}, ...
                    if (substr_count($part->subqtext, '{'.$placeholder.'}') == 0)  $subqreplaced .= '{'.$placeholder.'}';
                    if ($placeholder != '_u') // check whether it is a unit input box
                        $r = '<input type="text" class="'.'formulas_number '.$feedbackclass.'" '.$readonly.' title="'.'Expect a number'.'"'
                            .' name="'.$question->name_prefix.$var_name.'" value="'.$state->responses[$var_name].'"/>';
                    else if (strlen(trim($part->postunit)) != 0) // check whether the answer unit is not empty
                        $r = '<input type="text" class="'.'formulas_unit '.$feedbackclass.'" '.$readonly.' title="'.'Expect a unit'.'"'
                            .' name="'.$question->name_prefix.$var_name.'" value="'.$state->responses[$var_name].'"/>';
                    else
                        $r = ''; // the unit placeholder will be removed if the answer unit is empty
                    $subqreplaced = str_replace('{'.$placeholder.'}', $r, $subqreplaced);
                }
                echo $subqreplaced . $submit_mark . $feedback;
            }
        }
        echo $this->get_substituted_question_texts($question, $cmoptions, $vars, $question->options->posttext, '');
        
        if ($question->options->extra->peranswersubmit == 1) {  // special parameter to store which submit answer
            echo '<input type="hidden" '.$readonly.' id="'.$subans_track_id.'" name="'.$subans_track_id.'" value="-1"/>';
            // The following placeholder allows the additional POST name (respid_submit) to trigger the grading process...
            echo '<div id="'.$submit_location_id.'" style="display:none""></div>';
        }
        else {
            echo '<br><span title="'.$this->get_submission_info($question, $state, $cmoptions, $options, $i).'">';
            $this->print_question_submit_buttons($question, $state, $cmoptions, $options);
            echo '</span><br>';
        }
    }





    /// given a particular trial $trial_number, return the pair of maximum mark fraction and maximum number of trials. No throw
    function get_trial_mark_fraction(&$question, $trial_number) {
        if (!isset($question->options->retrymarkseq_parsed)) {   // reuse the results if it has been parsed before
            if (strlen(trim($question->options->extra->retrymarkseq)) == 0)  $mseq = array();
            else $mseq = explode(',', $question->options->extra->retrymarkseq);
            array_unshift($mseq, 1.0, 1.0);  // append two 1.0 (100%, full mark) for easy computation later.
            $question->options->retrymarkseq_loop = false;
            foreach ($mseq as $i => &$v)  if(is_numeric($v)) {
                $v = floatval($v);
                if (($i > 0 && $mseq[$i] > $mseq[$i-1]) || $mseq[$i]<0)
                    throw new Exception(get_string('error_retry_mark_order','qtype_formulas'));
            }
            else {
                if ($i == count($mseq)-1 && strlen(trim(end($mseq))) == 0) {
                    array_pop($mseq);
                    $question->options->retrymarkseq_loop = true;
                    break;
                }
                else throw new Exception(get_string('error_retry_mark_nonnumeric','qtype_formulas'));
            }
            $question->options->retrymarkseq_parsed = $mseq;
        }   // lazy evaluation, construct the retrymarkseq only when it is used somewhere
        $mseq = $question->options->retrymarkseq_parsed;
        
        if ($question->options->retrymarkseq_loop) { // different of the last two elements is being repeated
            if ($trial_number < count($mseq))  return array($mseq[$trial_number], -1);
            $repeat_penalty = $mseq[count($mseq)-2] - $mseq[count($mseq)-1];
            return array(max(0, $mseq[count($mseq)-1] - $repeat_penalty*($trial_number-count($mseq)+1)), -1);
        }
        else {  // with finite trial
            if ($trial_number < count($mseq))  return array($mseq[$trial_number], count($mseq)-1);
            return array(-1, count($mseq)-1);  // -1 indicates no further submission is allowed
        }
    }
    
    
    /// return the object of question_formulas_variables() with the global variable text defined in the $part. May throw error
    function get_global_variables(&$state) {
        if (!isset($state->globalvars)) {   // Perform lazy evaluation
            $vars = new question_formulas_variables($state->randomvars);
            $vars->evaluate_additional_variables($state->globalvars_text);
            $state->globalvars = $vars->get_variables();    // global variable always include random variable
        }   // Perform the evaluation only when the global variable does not exist before
        return new question_formulas_variables($state->globalvars);
    }
    
    
    /// return the object of question_formulas_variables() with the local variable defined in the $part. May throw error
    function get_local_variables($part, &$state) {
        if (!isset($state->localvars))  $state->localvars = array();
        if (!isset($state->localvars[$part->location])) {   // Perform lazy evaluation
            if (!isset($state->globalvars))  $this->get_global_variables(&$state);  // if global is not defined
            $vars = new question_formulas_variables($state->globalvars);
            $vars->evaluate_additional_variables($part->vars1);
            $state->localvars[$part->location] = $vars->get_variables();
        }   // Perform the evaluation only when the local variable does not exist before
        return new question_formulas_variables($state->localvars[$part->location]);
    }
    
    
    /// Override. A different grading scheme is used because we need to give a grade to each subanswer.
    function grade_responses(&$question, &$state, $cmoptions) {
        foreach ($question->options->answers as $answer)  foreach ($answer->names as $name) {
            $ex = explode("\n", isset($state->responses[$name]) ? $state->responses[$name] : '');
            $state->responses[$name] = trim($ex[0]); // remove endline character and fill the missing responses by an empty string
        }
        if ($question->options->extra->peranswersubmit == 1 && isset($state->responses['subanum']) &&
            $state->responses['subanum'] >= 0 && $state->responses['subanum'] < $question->options->numpart )
            $state->subanum = $state->responses['subanum'];    // the number indicate one particular answer is submitted
        else
            $state->subanum = -1;   // negative means all answers are submitted at once, all answers are graded
        
        $unit = new answer_unit_conversion; // it is defined here for the possibility of reusing parsed default set
        try {
            $state->raw_grades = $state->last_graded->raw_grades;
            $state->fractions = $state->last_graded->fractions;
            $state->trials = $state->last_graded->trials;
            foreach ($question->options->answers as $idx => $part)  if ($state->subanum == -1 || $state->subanum == $idx) {
                $tmf = $this->get_trial_mark_fraction($question, $state->trials[$idx]+1);
                if ($tmf[0] < 0)  continue;  // No further grading is allowed, which may be caused by browser resubmission
                $state->trials[$idx]++;
                $state->fractions[$idx] = $this->grade_responses_individually($part, $state, $state->responses, $unit);
                $raw_grade = $part->answermark * $state->fractions[$idx] * $tmf[0];
                $state->raw_grades[$idx] = max($raw_grade, $state->last_graded->raw_grades[$idx]);
            }
        } catch (Exception $e) {
            notify('Grading error! Probably result of incorrect import file or database corruption.');
            return false;// it should have no error when grading students question ...............
        }
        
        // The default additive penalty scheme is not used, so set penalty=0 and the raw_grade with penalty are directly computed
        $state->raw_grade = array_sum($state->raw_grades);
        $state->penalty = 0;

        // mark the state as graded
        $state->event = ($state->event ==  QUESTION_EVENTCLOSE) ? QUESTION_EVENTCLOSEANDGRADE : QUESTION_EVENTGRADE;
        return true;
    }
    
    
    /// parse the user input in the form of array of coordinates and post-unit
    function parse_response_into_coordinates_unit($part, $responses) {
        $number_regex = '[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?';
        $input = new stdClass;
        if (count($part->names) == 1) { // check whether it is a single input box or multiple one
            $response = trim($responses[$part->names[0]]);
            $ex = explode("\n", preg_replace('/^\s*\([^)]*\)/', "$0\n", $response, 1));
            if (count($ex) == 1) {
                $tmp = explode("\n", preg_replace('/^'.$number_regex.'/', "$0\n", $ex[0], 1));
                if (count($tmp) == 1)  throw new Exception('Wrong answer format, no number in response!');
                $input->coordinates[0] = $tmp[0];
                $input->postunit = $tmp[1];
            }
            else {
                $input->coordinates = explode(',', substr($ex[0],1,-1));
                if (count($input->coordinates) == 1)  throw new Exception('No brackets are allowed for number!');
                $input->postunit = $ex[1];
            }
        }
        else {
            foreach ($part->names as $name)  $input->coordinates[] = $responses[$name];
            $input->postunit = array_pop($input->coordinates);
        }
        
        foreach ($input->coordinates as &$v)  if (is_numeric($v))
            $v = floatval($v);
        else
            throw new Exception('Answer possibly contains non-numeric value: ' . $v);
        
        return $input;
    }
    
    
    /// grade the response and the unit together and return a single mark
    function grade_responses_individually($part, &$state, $responses, &$unit) {
        // parse the input into an objects with coordinates and postunit
        try {   // 0 marks will be given if either wrong format or answers contain empty string
            $input = $this->parse_response_into_coordinates_unit($part, $responses);
        } catch (Exception $e) { return 0; }
        
        // check whether the unit part of the response is possibly correct
        global $basic_unit_conversion_rules;
        $unit->assign_default_rules($part->ruleid, $basic_unit_conversion_rules[$part->ruleid][1]);
        $unit->assign_additional_rules($part->otherrule);
        $res2 = $unit->check_convertibility($input->postunit, $part->postunit);
        $cfactor = $res2->cfactor;
        $unit_correct = $res2->convertible;
        // unit should have no effect (i.e. always true) if the coordinates are all 0
        $is_origin = true;
        foreach ($input->coordinates as $i => $v)  $is_origin = $is_origin && ($v == 0);
        if ($is_origin)  $unit_correct = true;
        
        // check the correctness of the coordinate part
        $ans = $this->evaluate_answer($part, &$state);    // compute the predefined answer with vars, if any
        if (count($input->coordinates) != $ans->size)  return 0;
        $vars = $ans->vars;    // it contains the global and local variables before answer
        
        foreach ($input->coordinates as $idx => &$coord)  $coord *= $cfactor; // rescale response to match answer
        $this->add_special_correctness_variables($vars, $ans, $input);
        // evaluated the set of local variables preceding the correctness expression
        $vars->evaluate_additional_variables($part->vars2);
        // evaluate the $correctness_expression to determine whether the question is correct
        $correctness = $vars->evaluate_numerical_expression($part->correctness);
        
        $fraction = min(max((float) $correctness, 0.0), 1.0) * ($unit_correct ? 1 : (1-$part->unitpenalty));
        return $fraction;
    }
    
    
    /// add the set of special variables that may be useful to guage the correctness of the user input
    function add_special_correctness_variables(&$vars, $ans, $input) {
        //$vars->add('{_r}', $input->coordinates);
        foreach ($input->coordinates as $idx => $coord)  $vars->add_variable('_'.$idx.'', $coord);
        //$vars->add('{_ans}', $ans->coordinates);
        $sum0 = $sum1 = $sum2 = $maxerr = $relerr = $norm_sqr = 0;
        foreach ($ans->coordinates as $idx => $coord) {
            $norm_sqr += $coord*$coord;
            $diff[$idx] = $coord - $input->coordinates[$idx];
            //$match[$idx] = ($diff[$idx] == 0) ? 0 : 1;
            //$d1[$idx] = abs($diff[$idx]);
            $d2[$idx] = $diff[$idx]*$diff[$idx];
            $sum0 += ($diff[$idx] == 0) ? 0 : 1;
            $sum1 += abs($diff[$idx]);
            $sum2 += $d2[$idx];
            $maxerr = max($maxerr, abs($diff[$idx]));
        }
        $vars->add_variable('_diff', $diff);
        $vars->add_variable('_d', $d2);
        $vars->add_variable('_wrong', $sum0);
        $vars->add_variable('_sumerr', $sum1);
        $vars->add_variable('_err', sqrt($sum2));
        $vars->add_variable('_maxerr', $maxerr);
        $vars->add_variable('_relerr', sqrt($sum2/$norm_sqr)); // division by zero when exact match!
    }
    
    
    /// return the evaluated answer. Throw on error
    function evaluate_answer($part, &$state) {
        $res->vars = $this->get_local_variables($part, &$state);
        foreach ($part->coordinates as $coord)
            $res->coordinates[] = $res->vars->evaluate_numerical_expression($coord);
        $res->size = count($res->coordinates);
        return $res;
    }
    
    
    /// compute the correct response for the given subquestion part
    function get_correct_responses_individually($part, &$state) {
        try {
            $res = $this->evaluate_answer($part, &$state);
        } catch (Exception $e)  { return null; }
        $postunits = explode('=', $part->postunit, 2);
        if (count($part->names) == 1) {
            $responses[reset($part->names)] = trim(($res->size > 1 ? ' (' : ' ') . implode(',',$res->coordinates)
                . ($res->size > 1 ? ') ' : ' ') . trim($postunits[0]));
        }
        else {
            $names = array_values($part->names);
            $postunitname = array_pop($names);
            $responses[$postunitname] = $postunits[0];
            foreach ($names as $i => $name)  $responses[$name] = $res->coordinates[$i++];
        }
        return $responses;
    }
    
    
    /// compute the correct responses of each subquestion, if any
    function get_correct_responses(&$question, &$state) {
        $responses = array();
        foreach ($question->options->answers as $part) {
            $tmp = $this->get_correct_responses_individually($part, &$state);
            if ($tmp == null)  return null;
            $responses = array_merge($responses, $tmp);
        }
        return $responses;
    }
    
    
    /// Define the equivalence of the responses of subquestions
    function compare_responses(&$question, $state, $teststate) {
        $res = true;
        foreach ($question->options->answers as $part)  foreach ($part->names as $ph => $name)  {
            if ($ph == '_u' && $part->postunit == '')  continue;    // if no such responses, then skip
            $res = $res && (trim($state->responses[$name]) == $teststate->responses[$name]);
        }
        return $res;
    }
    
    
    /// Return a summary string of student responses. Need to override because it prints the data...
    function response_summary($question, $state, $length = 80) {
        $responses = $this->get_actual_response($question, $state);
        $summaries = '';
        foreach ($question->options->answers as $idx => $part)  if ($state->subanum == -1 || $state->subanum == $idx) {
            $c = question_get_feedback_class($state->fractions[$idx]);
            $res = array();
            foreach ($part->names as $name)  $res[] = $state->responses[$name]; // get the set of responses for this subquestion
            $summaries .= '<div class="'.$c.'">'.'<i>('.($idx+1).'.) </i> '.shorten_text(implode(" , ",$res), $length).'</div>';
        }
        return $summaries;
    }
    
    
    
    
    
    /**
     * Imports the question from Moodle XML format.
     *
     * @param $data structure containing the XML data
     * @param $question question object to fill: ignored by this function (assumed to be null)
     * @param $format format class exporting the question
     * @param $extra extra information (not required for importing this question in this format)
     */
    function import_from_xml(&$data,&$question,&$format,&$extra) {
        // return if type in the data is not coordinate question
        $nodeqtype = $data['@']['type'];
        if ($nodeqtype != $this->name() && $nodeqtype != 'coordinates')  return false;
        // Import the common question headers and set the corresponding field
        $qo = $format->import_headers($data);
        $qo->qtype = $this->name();
        $extras = $this->subquestion_option_extras();
        foreach ($extras as $extra)
            $qo->$extra = $format->getpath($data, array('#',$extra,0,'#','text',0,'#'),'',true);
        
        // Loop over each answer block found in the XML
        $tags = $this->subquestion_answer_tags();
        $answers = $data['#']['answers'];  
        foreach($answers as $answer) {
            foreach($tags as $tag) {
                $qotag = &$qo->$tag;
                $qotag[] = $format->getpath($answer, array('#',$tag,0,'#','text',0,'#'),'0',false,'error');
            }
        }
        $qo->defaultgrade = array_sum($qo->answermark); // make the defaultgrade consistent if not specified
        
        if ($nodeqtype == 'coordinates') {  // simple and dirty trick to import the old coordinates questions, works in most case
            var_dump('========== Import coordinates qtype to formulas qtype  ==========='); // Mainly update the syntax, and new placeholder method
            $qo->varsrandom = preg_replace('/\{(#?\w+)\}/', '${1}', $qo->varsrandom);   // remove bracket {} for variable
            $qo->varsrandom = preg_replace('/([0-9.eE+-]+)~([0-9.eE+-]+:[0-9.eE+-]+)/', '${1}:${2}', $qo->varsrandom);  // new range syntax
            $qo->varsrandom = preg_replace('/(shuffle\s*)\((\s*[0-9.eE+-]+(\s*,\s*[0-9.eE+-]+)+\s*)\)/', '${1}([${2}])', $qo->varsrandom);  // shuffle requires range
            $qo->varsrandom = preg_replace('/\((\s*[0-9.eE+-]+(\s*,\s*[0-9.eE+-]+)+\s*)\)/', '[${1}]', $qo->varsrandom);    // new tuple syntax
            $qo->varsglobal = preg_replace('/\{(#?\w+)\}/', '${1}', $qo->varsglobal);   // remove bracket {} for variable
            $qo->varsglobal = preg_replace('/(=\s*)\((\s*[0-9.eE+-]+(\s*,\s*[0-9.eE+-]+)+\s*)\)(\s*;)/', '${1}[${2}]$4', $qo->varsglobal);  // assign tuple
            foreach($tags as $tag)  foreach ($qo->$tag as &$entry) {
                $entry = preg_replace('/(=\s*)\((\s*[0-9.eE+-]+(\s*,\s*[0-9.eE+-]+)+\s*)\)(\s*;)/', '${1}[${2}]$4', $entry);    // assign tuple
                $entry = preg_replace('/\{(#?\w+)\}/', '${1}', $entry); // Variable in assignments do not require {} anymore
            }
            $filtered = $this->check_and_filter_answers($qo); // data from the web input interface should be validated
            if (isset($filtered->errors))   // there may be error from import or restore
                error('Error! Probably import/restore files have been damaged.');
            $ss = $this->create_subquestion_structure($qo->questiontext, $filtered->answers);
            $qo->questiontext = implode('{#}',$ss->pretexts).'{#}'.$ss->posttext; // replace the questiontext before stored in the database
            $qo->answerorders = $ss->answerorders;    // add the answerorders for later reordering
        }
        
        return $qo;
    }
    
    
    /**
     * Exports the question to Moodle XML format.
     *
     * @param $question question to be exported into XML format
     * @param $format format class exporting the question
     * @param $extra extra information (not required for exporting this question in this format)
     * @return text string containing the question data in XML format
     */
    function export_to_xml(&$question,&$format,&$extra) {
        $expout = '';
        $extras = $this->subquestion_option_extras();
        foreach ($extras as $extra)
            $expout .= "<$extra>".$format->writetext($question->options->extra->$extra)."</$extra>\n";
        
        $tags = $this->subquestion_answer_tags();
        foreach ($question->options->answers as $answer) {
            $expout .= "<answers>\n";
            foreach ($tags as $tag)
                $expout .= " <$tag>\n  ".$format->writetext($answer->$tag)." </$tag>\n";
            $expout .= "</answers>\n";
        }
        return $expout;
    }
    
    
    /**
     * Backup the data in the question to a backup file.
     *
     * This function is used by question/backuplib.php to create a copy of the data
     * in the question so that it can be restored at a later date. The method writes
     * all the supplementary coordinate data, including the answers of the subquestions.
     *
     * @param $bf the backup file to write the information to
     * @param $preferences backup preferences in effect (not used)
     * @param $questionid the ID number of the question being backed up
     * @param $level the indentation level of the data being written
     * 
     * @return bool true if the backup was successful, false if it failed.
     */
    function backup($bf,$preferences,$questionid,$level=6) {
        $question->id = $questionid;
        $this->get_question_options($question); // assume no error
        
        // Start tag of data
        $status = true;
        $status = $status && fwrite ($bf,start_tag('FORMULAS',$level,true));
        $extras = $this->subquestion_option_extras();
        foreach ($extras as $extra)
            fwrite ($bf,full_tag(strtoupper($extra), $level+1, false, $question->options->extra->$extra));
        
        // Iterate over each answer and write out its fields
        $tags = $this->subquestion_answer_tags();
        foreach ($question->options->answers as $var) {
            $status = $status && fwrite ($bf,start_tag('ANSWERS',$level+1,true));
            foreach ($tags as $tag)
                fwrite ($bf, full_tag(strtoupper($tag), $level+2, false, $var->$tag));
            $status = $status && fwrite ($bf,end_tag('ANSWERS',$level+1,true));
        }
        
        // End tag of data
        $status = $status && fwrite ($bf,end_tag('FORMULAS',$level,true));
        return $status;
    }
    
    
    /**
     * Restores the data in a backup file to produce the original question.
     *
     * This method is used by question/restorelib.php to restore questions saved in
     * a backup file to the database. It reads the file directly and writes the information
     * straight into the database.
     *
     * @param $old_question_id the original ID number of the question being restored
     * @param $new_question_id the new ID number of the question being restored
     * @param $info the XML parse tree containing all the restore information
     * @param $restore information about the current restore in progress
     * 
     * @return bool true if the backup was successful, false if it failed.
     */
    function restore($old_question_id,$new_question_id,$info,$restore) {
        $data = $info['#']['FORMULAS'][0];
        $qo = new stdClass;
        $qo->id          = $new_question_id;
        $qo->qtype       = $this->name();
        $extras = $this->subquestion_option_extras();
        foreach ($extras as $extra)
            $qo->$extra = backup_todb($data['#'][strtoupper($extra)]['0']['#']);
        
        // Loop over each answer block found in the XML
        $tags = $this->subquestion_answer_tags();
        $answers = $data['#']['ANSWERS'];  
        foreach($answers as $answer) {
            foreach($tags as $tag) {
                $qotag = &$qo->$tag;
                $qotag[] = backup_todb($answer['#'][strtoupper($tag)]['0']['#']);
            }
        }
        return is_bool($this->save_question_options($qo)) ? true : false;
    }
    
    
    
    
    
    /**
    * Validating the data returning from the client.
    * 
    * The check the basic error as well as the formula error by evaluating one instantiation.
    */
    function validation($data, $files) {
        global $basic_unit_conversion_rules;
        $form = (object)$data;
        $errors = array();
        
        if (substr_count($form->questiontext,'{#}') > 0)    // {#} is reserved and cannot be used in the main question text
            $errors["questiontext"] = get_string('error_qtext_reserved_str','qtype_formulas');
        
        try {
            $tmpquestion->options->extra->retrymarkseq = $form->retrymarkseq;
            $this->get_trial_mark_fraction(&$tmpquestion, 0);
        } catch (Exception $e) {
            $errors["retrymarkseq"] = $e->getMessage();
        }
        
        $answerschecked = $this->check_and_filter_answers($form);
        if (isset($answerschecked->errors))  $errors = array_merge($errors, $answerschecked->errors);
        $validanswers = $answerschecked->answers;
        
        $placeholdererrors = $this->check_placeholder($form->questiontext, $validanswers);
        $errors = array_merge($errors, $placeholdererrors);
        
        $subqformaterrors = $this->append_subanswer_format($validanswers);
        $errors = array_merge($errors, $subqformaterrors);
        
        try {
            $vars = new question_formulas_variables();
            $vars->parse_random_variables($form->varsrandom);
            $vars->instantiate_random_variables(); // instantiate a set of random variable
            $state->randomvars = $vars->get_variables();
        } catch (Exception $e) {
            $errors["varsrandom"] = $e->getMessage();
            return $errors;
        }
        
        try {
            $vars->evaluate_additional_variables($form->varsglobal);
        } catch (Exception $e) {
            $errors["varsglobal"] = $e->getMessage();
            return $errors;
        }
        $state->globalvars = $vars->get_variables();
        
        /// Attempt to compute the answer so that it can see whether it is wrong
        foreach ($validanswers as $idx => $ans) {
            $vars = new question_formulas_variables($state->globalvars);
            $unitcheck = new answer_unit_conversion;
            if ($ans->unitpenalty < 0 || $ans->unitpenalty > 1)
                $errors["unitpenalty[$idx]"] = get_string('error_unitpenalty','qtype_formulas') . $e->getMessage();
            try {
                $unitcheck->parse_targets($ans->postunit);
            } catch (Exception $e) {
                $errors["postunit[$idx]"] = get_string('error_validation_parse_rule','qtype_formulas') . $e->getMessage();
            }
            try {
                $unitcheck->assign_additional_rules($ans->otherrule);
                $unitcheck->reparse_all_rules();
            } catch (Exception $e) {
                $errors["otherrule[$idx]"] = get_string('error_validation_parse_rule','qtype_formulas') . $e->getMessage();
            }
            try {
                $entry = $basic_unit_conversion_rules[$ans->ruleid];
                if ($entry === null || $entry[1] === null)  throw new Exception(get_string('error_validation_ruleid','qtype_formulas'));
                $unitcheck->assign_default_rules($ans->ruleid, $entry[1]);
                $unitcheck->reparse_all_rules();
            } catch (Exception $e) {
                $errors["ruleid[$idx]"] = $e->getMessage();
            }
            try {
                $vars->evaluate_additional_variables($ans->vars1);
            } catch (Exception $e) {
                $errors["vars1[$idx]"] = get_string('error_validation_eval','qtype_formulas') . $e->getMessage();
                continue;
            }
            try {
                $input = $this->evaluate_answer($ans, &$state);
            } catch (Exception $e) {
                $errors["answer[$idx]"] = get_string('error_validation_eval','qtype_formulas') . $e->getMessage();
                continue;
            }
            try {
                $this->add_special_correctness_variables($vars, $input, $input);
                $vars->evaluate_additional_variables($ans->vars2);
            } catch (Exception $e) {
                $errors["vars2[$idx]"] = get_string('error_validation_eval','qtype_formulas') . $e->getMessage();
                continue;
            }
            try {
                $responses = $this->get_correct_responses_individually($ans, &$state);
                $correctness = $this->grade_responses_individually($ans, &$state, $responses, &$unitcheck);
            } catch (Exception $e) {
                $errors["correctness[$idx]"] = get_string('error_validation_eval','qtype_formulas') . $e->getMessage();
                continue;
            }
        }
        
        return $errors;
    }
    
    
    /**
     * Check that all required fields have been filled and return the filtered classes of the answers.
     * 
     * @param $form all the input form data
     * @return an object with a field 'answers' containing valid answers. Otherwise, the 'errors' field will be set
     */
    function check_and_filter_answers($form) {
        $tags = $this->subquestion_answer_tags();
        $res = (object)array('answers' => array());
        foreach ($form->answermark as $i=>$a) {
            if (strlen(trim($form->answermark[$i])) == 0)
                continue;   // if no mark, then skip this answers
            if (floatval($form->answermark[$i]) <= 0)
                $res->errors["answermark[$i]"] = get_string('error_mark','qtype_formulas');
            $skip = false;
            if (strlen(trim($form->answer[$i])) == 0) {
                $res->errors["answer[$i]"] = get_string('error_answer_missing','qtype_formulas');
                $skip = true;
            }
            if (strlen(trim($form->correctness[$i])) == 0) {
                $res->errors["correctness[$i]"] = get_string('error_correctness_missing','qtype_formulas');
                $skip = true;
            }
            if ($skip)  continue;   // if no answer or correctness conditions, it cannot check other parts, so skip
            $res->answers[$i] = (object)array('questionid' => $form->id);   // create an object of answer with the id
            foreach ($tags as $tag)  $res->answers[$i]->{$tag} = trim($form->{$tag}[$i]);
        }
        if (count($res->answers) == 0)
            $res->errors["answermark[0]"] = get_string('error_no_answer','qtype_formulas');
        return $res;
    }
    
    
    /**
     * Split and reorder the main question by the placeholders. The check_placeholder() should be called before
     * 
     * @param string $questiontext The input question text containing a set of placeholder
     * @param array $answers Array of answers, containing the placeholder name  (must not empty)
     * @return Return the object with field answerorders, pretexts and posttext.
     */
    function create_subquestion_structure($questiontext, $answers) {
        $locations = array();   // store the (scaled) location of the *named* placeholder in the main text
        foreach ($answers as $idx => $answer)  if (strlen($answer->placeholder) != 0)
            $locations[] = 1000*strpos($questiontext, '{'.$answer->placeholder.'}') + $idx; // store the pair (location, idx)
        sort($locations);       // performs stable sort of location and answerorder pair
        
        $ss = new stdClass();
        foreach ($locations as $i => $location) {
            $answerorder = $location%1000;
            $ss->answerorders[] = $answerorder; // store the new location of the placeholder in the main text
            list($ss->pretexts[$i],$questiontext) = explode('{'.$answers[$answerorder]->placeholder.'}', $questiontext);
        }
        foreach ($answers as $idx => $answer)  if (strlen($answer->placeholder) == 0) { // add the empty placeholder at the end
            $ss->answerorders[] = $idx;
            $ss->pretexts[] = $questiontext;
            $questiontext = '';
        }
        $ss->posttext = $questiontext;  // add the post-question text, if any
        
        return $ss;
    }
    
    
    /// Add three fields, coordinates, subqformated and names to the objects $answers. Return errors array if any
    function append_subanswer_format(&$answers) {
        foreach ($answers as $idx => &$part) {
            if (strlen(trim($part->answer)) == 0)
                $errors["answer[$idx]"] = get_string('error_answer_empty','qtype_formulas');
            $part->coordinates = explode(';',$part->answer);
            foreach ($part->coordinates as $coordinate)  if (strlen(trim($coordinate)) == 0)
                $errors["answer[$idx]"] = get_string('error_answer_missing','qtype_formulas');
            
            $any_placeholder = false;
            $part->names = array();
            for ($i=0; $i<count($part->coordinates)+1; $i++) {
                $name = ($i == count($part->coordinates)) ? "_u" : "_$i";   // the last one is unit
                $ph = "{".$name."}";
                $part->names[$name] = $idx.$name;
                $count = substr_count($part->subqtext, $ph);
                if ($count > 1)  $errors["subqtext[$idx]"] = get_string('error_subqtext_placeholder_duplicate','qtype_formulas').$ph;
                if ($count == 1)  $any_placeholder = true;
            }
            $part->names = $any_placeholder ? $part->names : array(0 => $idx.'_');
            $part->location = $idx;
            $part->fraction = 1;        // used by get_all_responses()
        }
        return isset($errors) ? $errors : array();
    }
    
    
    /// check whether the placeholder in the $answers is correct and compatible with $questiontext
    function check_placeholder($questiontext, $answers) {
        $placeholder_format = '#\w+';
        $placeholders = array();
        foreach ($answers as $idx => $answer) {
            if ( strlen($answer->placeholder) == 0 )  continue; // no error for empty placeholder
            $errstr = '';
            if ( strlen($answer->placeholder) >= 40 ) 
                $errstr .= get_string('error_placeholder_too_long','qtype_formulas');
            if ( !preg_match('/^'.$placeholder_format.'$/', $answer->placeholder) )
                $errstr .= get_string('error_placeholder_format','qtype_formulas');
            if ( array_key_exists($answer->placeholder, $placeholders) )
                $errstr .= get_string('error_placeholder_sub_duplicate','qtype_formulas');
            $placeholders[$answer->placeholder] = true;
            $count = substr_count($questiontext, $answer->placeholder);
            if ($count<1)
                $errstr .= get_string('error_placeholder_missing','qtype_formulas');
            if ($count>1)
                $errstr .= get_string('error_placeholder_main_duplicate','qtype_formulas');
            if (strlen($errstr) != 0)  $errors["placeholder[$idx]"] = $errstr;
        }
        return isset($errors) ? $errors : array();
    }
    
}

// Register this question type with the system.
question_register_questiontype(new question_formulas_qtype());
?>
